aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx')
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx294
1 files changed, 294 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
new file mode 100644
index 0000000..3327a42
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -0,0 +1,294 @@
+import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import classNames from 'classnames';
+import { useMemo, useState } from 'react';
+import { firstBy } from 'thenby';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
+import { File } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+import { objectToArray } from '@/lib/data';
+import { formatLongNumber } from '@/lib/format';
+import styles from './Journey.module.css';
+
+const NODE_HEIGHT = 60;
+const NODE_GAP = 10;
+const LINE_WIDTH = 3;
+
+export interface JourneyProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [activeNode, setActiveNode] = useState(null);
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('journey', {
+ websiteId,
+ steps,
+ startStep,
+ endStep,
+ });
+
+ useEscapeKey(() => setSelectedNode(null));
+
+ const columns = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ const selectedPaths = selectedNode?.paths ?? [];
+ const activePaths = activeNode?.paths ?? [];
+ const columns = [];
+
+ for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
+ const nodes = {};
+
+ data.forEach(({ items, count }: any, nodeIndex: any) => {
+ const name = items[columnIndex];
+
+ if (name) {
+ const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
+ const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
+
+ if (!nodes[name]) {
+ const paths = data.filter(({ items }) => items[columnIndex] === name);
+
+ nodes[name] = {
+ name,
+ count,
+ totalCount: count,
+ nodeIndex,
+ columnIndex,
+ selected,
+ active,
+ paths,
+ pathMap: paths.map(({ items, count }) => ({
+ [`${columnIndex}:${items.join(':')}`]: count,
+ })),
+ };
+ } else {
+ nodes[name].totalCount += count;
+ }
+ }
+ });
+
+ columns.push({
+ nodes: objectToArray(nodes).sort(firstBy('total', -1)),
+ });
+ }
+
+ columns.forEach((column, columnIndex) => {
+ const nodes = column.nodes.map(
+ (
+ currentNode: { totalCount: number; name: string; selected: boolean },
+ currentNodeIndex: any,
+ ) => {
+ const previousNodes = columns[columnIndex - 1]?.nodes;
+ let selectedCount = previousNodes ? 0 : currentNode.totalCount;
+ let activeCount = selectedCount;
+
+ const lines =
+ previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
+ const fromCount = selectedNode?.paths.reduce((sum, path) => {
+ if (
+ previousNode.name === path.items[columnIndex - 1] &&
+ currentNode.name === path.items[columnIndex]
+ ) {
+ sum += path.count;
+ }
+ return sum;
+ }, 0);
+
+ if (currentNode.selected && previousNode.selected && fromCount) {
+ arr.push([previousNodeIndex, currentNodeIndex]);
+ selectedCount += fromCount;
+
+ if (previousNode.active) {
+ activeCount += fromCount;
+ }
+ }
+
+ return arr;
+ }, []) || [];
+
+ return { ...currentNode, selectedCount, activeCount, lines };
+ },
+ );
+
+ const visitorCount = nodes.reduce(
+ (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
+ if (!selectedNode) {
+ sum += totalCount;
+ } else if (!activeNode && selectedNode && selected) {
+ sum += selectedCount;
+ } else if (activeNode && active) {
+ sum += activeCount;
+ }
+ return sum;
+ },
+ 0,
+ );
+
+ const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
+ const dropOff =
+ previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
+
+ Object.assign(column, { nodes, visitorCount, dropOff });
+ });
+
+ return columns;
+ }, [data, selectedNode, activeNode]);
+
+ const handleClick = (name: string, columnIndex: number, paths: any[]) => {
+ if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
+ setSelectedNode({ name, columnIndex, paths });
+ } else {
+ setSelectedNode(null);
+ }
+ setActiveNode(null);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
+ <div className={styles.container}>
+ <div className={styles.view}>
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+ <div
+ key={columnIndex}
+ className={classNames(styles.column, {
+ [styles.selected]: selectedNode,
+ [styles.active]: activeNode,
+ })}
+ >
+ <div className={styles.header}>
+ <div className={styles.num}>{columnIndex + 1}</div>
+ <div className={styles.stats}>
+ <div className={styles.visitors} title={visitorCount}>
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+ </div>
+ </div>
+ </div>
+ <div className={styles.nodes}>
+ {nodes.map(
+ ({
+ name,
+ totalCount,
+ selected,
+ active,
+ paths,
+ activeCount,
+ selectedCount,
+ lines,
+ }) => {
+ const nodeCount = selected
+ ? active
+ ? activeCount
+ : selectedCount
+ : totalCount;
+
+ const remaining =
+ columnIndex > 0
+ ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
+ : 0;
+
+ const dropped = 100 - remaining;
+
+ return (
+ <div
+ key={name}
+ className={styles.wrapper}
+ onMouseEnter={() =>
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+ <div
+ className={classNames(styles.node, {
+ [styles.selected]: selected,
+ [styles.active]: active,
+ })}
+ onClick={() => handleClick(name, columnIndex, paths)}
+ >
+ <Row alignItems="center" className={styles.name} title={name} gap>
+ <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
+ <Text truncate>{name}</Text>
+ </Row>
+ <div className={styles.count} title={nodeCount}>
+ <TooltipTrigger
+ delay={0}
+ isDisabled={columnIndex === 0 || (selectedNode && !selected)}
+ >
+ <Focusable>
+ <div>{formatLongNumber(nodeCount)}</div>
+ </Focusable>
+ <Tooltip placement="top" offset={20} showArrow>
+ <Text transform="lowercase" color="ruby">
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+ </Text>
+ <Column>
+ <Text transform="lowercase">
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+ </Text>
+ </Column>
+ </Tooltip>
+ </TooltipTrigger>
+ </div>
+ {columnIndex < columns.length &&
+ lines.map(([fromIndex, nodeIndex], i) => {
+ const height =
+ (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
+ NODE_GAP;
+ const midHeight =
+ (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
+ NODE_GAP +
+ LINE_WIDTH;
+ const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
+
+ return (
+ <div
+ key={`${fromIndex}${nodeIndex}${i}`}
+ className={classNames(styles.line, {
+ [styles.active]:
+ active &&
+ activeNode?.paths.find(
+ (path: { items: any[] }) =>
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+ <div className={classNames(styles.segment, styles.start)} />
+ <div
+ className={classNames(styles.segment, styles.mid)}
+ style={{
+ height: midHeight,
+ }}
+ />
+ <div className={classNames(styles.segment, styles.end)} />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ },
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </LoadingPanel>
+ );
+}